---@class AGRange
AGRange = {}
--[[
    单位换算:

    1NM = 1852 m = 6076.115 ft

    1 m = 3.279 ft
    1 ft = 0.305 m

    1 knot = 0.514 m/s
    1 m/s = 1.944 knots

    1 kg = 2.205 lbs

]]

--[[
    TODO:
    1. 预计功能：
        一般对地攻击
            1. 固定目标 done
            2. 移动目标 done
            3. 机场目标
        近距离空中支援(CAS + JTAC)
        防空压制(SEAD)
        攻击效果汇报 done

    2. 在F10地图上显示每个Range的信息, 比如: 是否激活, 使用者, 训练类型, 剩余目标数量等. done

]]

do
--- ========== Config ==========
    local DebugMod = false

    local DEFAULT_RANGE_RADUIS = 9000 --in meters.
    local DEFAULT_RANGE_SPACING = 1000 --in meters.
    local DEFAULT_RANGE_HEADING = 90 

    local DEFAULT_SMOKE_DISAPPEAR_TIME = 300 --Seconds.

    -- Spawn relative
    local TotalTargetNum = 12
    local TotalRedNum = 8
    local TotalBlueNum = TotalTargetNum-TotalRedNum
    local TotalStaticNum = TotalTargetNum/2
    local TotalMovingNum = TotalTargetNum/2

    local DEFAULT_TARGET_SPEED = {
        min = 20/3.6, --m/s, about 20km/h
        max = 60/3.6, --m/s, about 60km/h
    }

    local RangeType = {
        NORMAL = "Normal",
        HELI = "Heli",
        AIRBASE = "Airbase",
        SEAD = "SEAD",
        SHIP = "Anti-Ship",
    }

    local Range_Status = {
        Neutral = 1,
        Activated = 2,
    }

    local Color = {
        [Range_Status.Neutral] = {
            color = {1,1,1,1},
            fill = {0,0,0,0.2},
            lineType = 2 
        },
        [Range_Status.Activated] = {
           color = {0,0,1,1},
           fill = {0,0,1,0.2},
           lineType = 1,
        },
    }

    local TextColor = {
        [Range_Status.Neutral] = {
            color = {0,0,0,0},
            fill = {0,0,0,0},
        },
        [Range_Status.Activated] = {
           color = {1,1,1,1},
           fill = {0,0,1,0.2},
        },
        fontSize = 18, 
    }

    local SpawnCoalition = {
        [coalition.side.RED] = country.id.CJTF_RED,
        [coalition.side.BLUE] = country.id.CJTF_BLUE,
    }

    local SpawnedGroups_Default = {
        [coalition.side.RED] = {},
        [coalition.side.BLUE] = {},
    }

    local PlayerData_Default = {
        startTime = nil,
        enemyKilled = 0,
        allyKilled = 0,
        cloesestEnemyDistance = 15000,
    }

    local theatre = env.mission.theatre
    AGRange.MonitorRepeatTime = 5 -- seconds

--- ========== Table ==========
    -- Table of basic range object storage.
    local registeredAirbase = {} -- Store all airbases found in range zone.
    AGRange.AllRanges = {} -- Store all range objects.
    AGRange.RangeZoneNames = {} -- Use to assign default range name index.

    -- Table of extra indexing management.
    AGRange.RangeTypes = {} -- Store range by range type.
    AGRange.RangePlayers = {}-- Store active range and it's player Group.

    AGRange.menu = {}

    AGRange.TargetTemplates = {}
    for rangeType,typeName in pairs(RangeType) do
        AGRange.TargetTemplates[typeName] = {
            [coalition.side.RED] = {},
            [coalition.side.BLUE] = {},
        }
    end

    local function Debug(msg)
        if not DebugMod then return end
        Utils.Debug(msg)
    end

--- ===== 繁琐的"DCS 脚本体操" =====
    --- Get Group
    ---@param groupIdent string|Group Can be group name or DCS.Group.
    ---@return Group|nil
    local function getGroup(groupIdent)
        Debug('AGRange.getGroup')
        if not groupIdent then return end

        local group = groupIdent

        if type(groupIdent)=='string' then
            group = Group.getByName(groupIdent)
        end

        if not group or not group.isExist or not group:isExist() then return end
        if not group.getUnit or not group.getUnits then return end
        if not group.getController then return end
        if not group.getID then return end
        if not group.getName then return end
        if not group.getCoalition then return end

        return group
    end
    --- Get Unit
    ---@param DCSunit Unit|string a DCS.Unit object or its name.
    ---@param alive boolean if true will check if this unit is alive.
    ---@param player boolean if true will check if this unit controlled by player.
    ---@return Unit|nil
    local function getUnit(DCSunit,alive,player)
        Debug('AGRange.getUnit')

        local unit = DCSunit
        if type(DCSunit)=='string' then
            unit = Unit.getByName(DCSunit)
        end

        if not unit then return end
        if not unit.getGroup then return end
        if not unit.getTypeName then return end
        if not unit.getID then return end
        if not unit.getCoalition then return end
        if not unit.getController then return end

        if not unit.isExist or not unit:isExist() then return end

        if alive then
            if not unit.getLife or unit:getLife()<1 then return end
        end

        if player then
            if not unit.getPlayerName or not unit:getPlayerName() then return end
        end

        return unit
    end

    --- Get unit(1)
    ---@param DCSgroup Group a DCS.Group object.
    ---@param alive boolean if true will check if this unit is alive.
    ---@param player boolean if true will check if this unit controlled by player.
    ---@return Unit|nil
    local function getFirstUnit(DCSgroup,alive,player)
        Debug('AGRange.getFirstUnit')
        local group = getGroup(DCSgroup)
        if not group then return end

        local unit = getUnit(group:getUnit(1),alive,player)
        if not unit then return end

        return unit
    end

--- ===== Private functions =====
    --- Get square from a DCS.zone|point
    ---@param center vec3 Center point of this square.
    ---@param length number Length of this square, nil for default.
    ---@return table 4 vec3 points of a polygon.
    local function getSquare(center,length,heading)
        if not center or type(center)~='table' then return end

        local center = Utils.makeVec3(center)
        local length = length/2 or DEFAULT_RANGE_RADUIS/2
        local heading = heading or DEFAULT_RANGE_HEADING

        local diagonal = math.sqrt(2)*length

        local polygon = {
            [1] = Utils.makeVec3(Utils.vecTranslate(center,heading+135,diagonal)),
            [2] = Utils.makeVec3(Utils.vecTranslate(center,heading+45,diagonal)),
            [3] = Utils.makeVec3(Utils.vecTranslate(center,heading-45,diagonal)),
            [4] = Utils.makeVec3(Utils.vecTranslate(center,heading-135,diagonal)),
        }

        return polygon
    end

    ---@return number range index number.
    local function getRangeIndex()
        for i=1,#AGRange.RangeZoneNames+1,1 do
            if not AGRange.RangeZoneNames[i] then
                return i
            end
        end
    end

    --- Get available raange in this range type.
    ---@param rangeType string rangeType
    ---@return AGRange|nil
    local function getAvailableRange(rangeType)
        if not rangeType or type(rangeType)~='string' then return nil end

        local rangeZones = AGRange.RangeTypes[rangeType]
        for i, rangeZone in pairs(rangeZones) do
            if not rangeZone.playerGroup then
                return rangeZone
            end
        end

        return nil
    end
        
    ---@param obj table 
    ---@param obj.point table Center point of this zone.
    ---@param obj.verticies table|nil Points of this polygon zone.
    ---@param radius number|nil Radius of this zone.
    ---@return table|nil airbase object if found.
    local function getAirbase(obj)
        if not obj or type(obj)~='table' then return end

        if not obj.point then return end
        if type(obj.point)~='table' then return end

        local volS = {
            id = world.VolumeType.SPHERE,
            params = {
                point = obj.point,
                radius = obj.radius,
            }
        }

        if obj.verticies and Utils.getTableSize(obj.verticies) >= 3 then
            volS = {
                id = world.VolumeType.BOX,
                params = {
                    min = {
                        x= obj.verticies[1].x,
                        y= land.getHeight({x=obj.verticies[1].x,y=obj.verticies[1].z})-100,
                        z= obj.verticies[1].z,
                    },
                    max = {
                        x= obj.verticies[3].x,
                        y= land.getHeight({x=obj.verticies[3].x,y=obj.verticies[3].z})+100,
                        z= obj.verticies[3].z,
                    },
                }
            }
        end

        local airbase = nil
        world.searchObjects(Object.Category.BASE,volS,function (foundAirbase,val)
            airbase = foundAirbase
            return true
        end)
        
        return airbase
    end

    local function getTaskOffRoad(startPoint,endPoint,speed)
        local Mission = {
            id = 'Mission',
            params = {
                route = {
                    points = {
                        [1] = {
                            x = startPoint.x,
                            y = startPoint.y,
                            speed = speed,
                            type = AI.Task.WaypointType.TURNING_POINT,
                            action = AI.Task.VehicleFormation.OFF_ROAD,
                        },
                        [2] = {
                            x = endPoint.x,
                            y = endPoint.y,
                            speed = speed,
                            type = AI.Task.WaypointType.TURNING_POINT,
                            action = AI.Task.VehicleFormation.OFF_ROAD,
                        }
                    },
                },
            },
        }

        return Mission
    end

    local function Format_time(total_seconds)
        -- 确保三位小数格式并处理浮点精度问题
        local formatted = string.format("%.3f", total_seconds)
        local integer_part, decimal_part = formatted:match("^(%d+)%.(%d%d%d)$")
        local integer_seconds = tonumber(integer_part)
        local milliseconds = tonumber(decimal_part)
    
        -- 计算时分秒
        local hours = math.floor(integer_seconds / 3600)
        local remaining = integer_seconds % 3600
        local minutes = math.floor(remaining / 60)
        local seconds = remaining % 60
    
        -- 格式化输出为HH:MM:SS.sss
        -- return string.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds)
        return string.format("%02d:%02d:%02d", hours, minutes, seconds)
    end

    --[[
        函数：decimal_to_dms
        功能：将十进制度坐标转换为度分秒格式（带方向标识）
        参数：decimal (十进制度数值), is_latitude (是否为纬度)
        返回：格式如 "N 41°28'46.44"" 的字符串
    --]]
    local function Decimal_to_dms(decimal, is_latitude)
        local degrees = math.floor(math.abs(decimal))
        local remainder = (math.abs(decimal) - degrees) * 60
        local minutes = math.floor(remainder)
        local seconds =  math.floor((remainder - minutes)*1000)

        -- 确定方向标识
        local direction
        if is_latitude then
            direction = (decimal >= 0) and "N" or "S"
        else
            direction = (decimal >= 0) and "E" or "W"
        end

        -- 格式化输出（秒保留两位小数）
        return string.format("%s %02d°%02d.%03d\"", direction, degrees, minutes, seconds)
    end

    ---@param vec3 vec3
    ---@param tType string which form you want return: 'MGRS'|'latlon' or 'LL'.
    ---@return string text of this coord.
    local function Format_Coord(vec3,tType)
        if not vec3 then return end
        if not tType then return end

        
        local oriPoint = Utils.makeVec3(vec3)
    
        if tType == 'MGRS' then
            local lat,lon,alt = coord.LOtoLL(oriPoint)
            local MGRSTable = coord.LLtoMGRS(lat,lon)

            -- return MGRSTable.UTMZone .. ' ' .. MGRSTable.MGRSDigraph .. ' ' .. MGRSTable.Easting .. ' ' .. MGRSTable.Northing
            return string.format('%03s %s %05s %05s',MGRSTable.UTMZone,MGRSTable.MGRSDigraph,MGRSTable.Easting,MGRSTable.Northing)
        end

        if tType == 'latlon' or tType == 'LL' then
            local lat,lon,alt = coord.LOtoLL(oriPoint)

            return Decimal_to_dms(lat,true)..' '..Decimal_to_dms(lon)
        end
    end

    function AGRange.getDefaultRangeRadius()
        return DEFAULT_RANGE_RADUIS
    end

    function AGRange.getDefaultRangeSpacing()
        return DEFAULT_RANGE_SPACING
    end

--- ===== Public Functions =====
    --- Get a new AGRange object, require at least a "vec2" point.
    ---@param params table|string, Can be a [center point] of this zone or [Dcs.Zone] object or just a [Dcs.Zone] object name.
    ---@param params.point vec2|vec3, Center point of range zone.
    ---@param params.radius number|nil, Radius of range zone.
    ---@param params.heading number|nil, Hedaing of range zone, point[1](south west) to point[2](south east).
    ---@param params.name string|nil, Name of range zone, nil for auto generate.
    ---@param params.rangeType string|nil, Type of range zone, default is "Normal".
    ---@return AGRange
    function AGRange:New(params)
        if not params then return end

        -- If param is zoneName.
        if type(params)=='string' then
            local temp = params
            params = {}
            params.zoneName = temp

            local zone = Utils.getZone(params.zoneName)
            if not zone then return end
            params.point = zone.point
            params.radius = params.radius or zone.radius
            params.verticies = zone.verticies
        end

        -- If param is a vec table.
        if type(params)=='table' then
            if params.x and params.y then
                local temp = Utils.deepCopy(params)
                params = {}
                params.point = temp
                params.radius = params.radius or DEFAULT_RANGE_RADUIS
            end
        end

        if not params.point then return end
        params.rangeType = params.rangeType or RangeType.NORMAL

        local obj = {}
        local index = getRangeIndex()
        obj.index = index
        obj.rangeType = params.rangeType or RangeType.NORMAL
        obj.name = params.name or 'Range-'..index..'-'..obj.rangeType
        
        obj.point = Utils.makeVec3(params.point)
        obj.heading = params.heading or DEFAULT_RANGE_HEADING
        obj.radius = params.radius or DEFAULT_RANGE_RADUIS
        obj.verticies = Utils.deepCopy(params.verticies) or getSquare(obj.point, obj.radius, obj.heading)
        if not obj.verticies or Utils.getTableSize(obj.verticies) < 4 then

            return 
        end

        obj.status = Range_Status.Neutral

        local airbase = getAirbase(obj)
        if airbase then
            local airbaseName = airbase:getName()

            if not registeredAirbase[airbaseName] then
                obj.rangeType = RangeType.AIRBASE
                obj.name = params.zoneName or 'Range-'..index..'-'..obj.rangeType

                obj.airbase = airbase
                obj.airbase:autoCapture(false)
                obj.airbase:setCoalition(coalition.side.NEUTRAL)
                registeredAirbase[airbaseName] = obj.name
                
                Debug('foundAirbase: '..airbaseName..' for '..obj.name)
            end
        end



        setmetatable(obj, self)
        self.__index = self

        AGRange.AllRanges[obj.name] = obj
        AGRange.RangeZoneNames[index] = obj.name

        AGRange.RangeTypes[obj.rangeType] = AGRange.RangeTypes[obj.rangeType] or {}
        table.insert(AGRange.RangeTypes[obj.rangeType],obj)

        obj:Init(true)

        return obj
    end

    function AGRange:Remove()
        if not AGRange.AllRanges[self.name] then return end

        if self.airbase then
            if registeredAirbase[self.airbase:getName()] then
                registeredAirbase[self.airbase:getName()] = nil
            end
        end

        for i,data in ipairs(AGRange.RangeTypes[self.rangeType]) do
            if data.name == self.name then
                data = nil
            end
        end

        AGRange.RangeZoneNames[self.index] = nil
        AGRange.RangePlayers[self.name] = nil
    end

    --- Init range
    ---@param deregisterRange boolean if true, set everything to initial.
    function AGRange:Init(deregisterRange)
        if not self then return end

        Debug('AGRange:Init')
        self:Stop()
        self:DespawnTargets()

        if deregisterRange then
            self.spawnedGroups = Utils.deepCopy(SpawnedGroups_Default)
            self.playerData = Utils.deepCopy(PlayerData_Default)
            self.playerGroup = nil
            self.playSound = true

            self:setNeutral()
        end

        return self
    end

    --- Set zone active
    ---@param playerGroupName string DCS.group name for whom assigned to this zone.
    ---@return AGRange
    function AGRange:setActive(playerGroupName)
        Debug('AGRange:setActive')
        
        self.status = Range_Status.Activated

        self:DrawZone()
        return self
    end

    --- Set zone Neutral
    function AGRange:setNeutral()
        Debug('AGRange:setNeutral')
        
        -- if self.status == Range_Status.Neutral then return self end

        self.playerGroupName = nil
        self.status = Range_Status.Neutral 

        self:DrawZone()
        return self
    end
    
    --- Draw range zone on F10 map.
    function AGRange:DrawZone(color,fill,lineType)
        Debug('AGRange:DrawZone')
        
        if not self then return false end

        color = color or Color[self.status].color
        fill = fill or Color[self.status].fill
        lineType = lineType or Color[self.status].lineType

        local textColor = TextColor[self.status].color
        local textFill = TextColor[self.status].fill
        local fontSize = TextColor.fontSize

        if not self.DrawID then
            self.DrawID = Utils.getMarkupID()
            Utils.drawPolygon(-1,self.DrawID,self.verticies,color,fill,lineType)
        end

        if self.DrawID then
            trigger.action.setMarkupColor(self.DrawID,color)
            trigger.action.setMarkupColorFill(self.DrawID,fill)
            trigger.action.setMarkupTypeLine(self.DrawID,lineType)
        end

        local playerUnit = getFirstUnit(self.playerGroup,true,true)
        Debug("playerUnit:") --Debug
        Debug(playerUnit) --Debug

        local labelText = ''
        -- local textPoint = Utils.makeVec3(Utils.vecTranslate(self.point,self.heading+180,(self.radius/2)*2/3))
        local textPoint = Utils.makeVec3(self.verticies[4])

        if playerUnit then
            labelText = Localizations:translate('Current User: ','cn')..playerUnit:getPlayerName()
        end

        if not self.TextID then
            self.TextID = Utils.getMarkupID()
            trigger.action.textToAll(-1,self.TextID,textPoint,textColor,textFill,fontSize,false,labelText)
        end
        
        if self.TextID then
            trigger.action.setMarkupColor(self.TextID,textColor)
            trigger.action.setMarkupColorFill(self.TextID,textFill)
            trigger.action.setMarkupText(self.TextID,labelText)
        end

        return self
    end

    --- Set group alarm state to "Green".
    ---@param Controller Controller DCS.Controller object.
    function AGRange.setGroupAlarm_Green(Controller)
        Debug('AGRange:setGroupAlarm_Green')
        if not Controller then return end

        Controller:setOption(AI.Option.Ground.id.ROE,AI.Option.Ground.val.ROE.WEAPON_HOLD)
        Controller:setOption(AI.Option.Ground.id.ALARM_STATE,AI.Option.Ground.val.ALARM_STATE.GREEN)
    end

    function AGRange:GetRandSpawnPoint()
        Debug('AGRange:GetRandSpawnPoint')
        
        for i=1,100,1 do
            local spawnPoint = Utils.getRandPointInCircle(self.point,self.radius/2)
            local landType =land.getSurfaceType(spawnPoint)
            if landType == land.SurfaceType.LAND or landType == land.SurfaceType.ROAD then
                if theatre == 'GermanyCW' then return spawnPoint end

                local volS = {
                    id = world.VolumeType.SPHERE,
                    params = {
                        point = spawnPoint,
                        radius = 10,
                    }
                }

                local result = world.searchObjects(Object.Category.SCENERY,volS,function (findObj,val) end)

                if result < 1 then
                    return spawnPoint
                end
            end
        end

        return nil
    end

    function AGRange:getTaskOnRoad(startPoint,endPoint,speed)
        Debug('AGRange:getTaskOnRoad')

        --- 检测路径是否在靶场范围内
        -- 获取路径点
        local pathPoint = land.findPathOnRoads('roads',startPoint.x,startPoint.y,endPoint.x,endPoint.y)
        if not pathPoint or Utils.getTableSize(pathPoint) < 1 then return getTaskOffRoad(startPoint,endPoint,speed) end

        -- 筛选转向点
        local turnPoints = {}
        local baseHeading = Utils.getDirection(startPoint,pathPoint[1])
        for i, point in ipairs(pathPoint) do
            if i>2 then
                local newHeading = Utils.getDirection(pathPoint[i-1],pathPoint[i])
                if baseHeading ~= newHeading then
                    baseHeading = newHeading
                    table.insert(turnPoints,point)
                end
            end
        end
        -- 检查转向点是否有在靶场范围外的
        for i, turnPoint in ipairs(turnPoints) do
            if not Utils.pointInPolygon(turnPoint,self.verticies) then
                return getTaskOffRoad(startPoint,endPoint,speed)
            end
        end

        local RoadPoint_Start = {}
        RoadPoint_Start.x, RoadPoint_Start.y = land.getClosestPointOnRoads('roads',startPoint.x,startPoint.y)

        local RoadPoint_End = {}
        RoadPoint_End.x,RoadPoint_End.y = land.getClosestPointOnRoads('roads',endPoint.x,endPoint.y)

        local Mission = {
            id = 'Mission',
            params = {
                route = {
                    points = {
                        [1] = {
                            x = RoadPoint_Start.x,
                            y = RoadPoint_Start.y,
                            speed = speed,
                            type = AI.Task.WaypointType.TURNING_POINT,
                            action = AI.Task.VehicleFormation.ON_ROAD,
                        },
                        [2] = {
                            x = RoadPoint_End.x,
                            y = RoadPoint_End.y,
                            speed = speed,
                            type = AI.Task.WaypointType.TURNING_POINT,
                            action = AI.Task.VehicleFormation.ON_ROAD,
                            
                        },
                        [3] = {
                            x = endPoint.x,
                            y = endPoint.y,
                            speed = speed,
                            type = AI.Task.WaypointType.TURNING_POINT,
                            action = AI.Task.VehicleFormation.OFF_ROAD,
                        }
                    },
                },
            },
        }

        return Mission
    end

    --- Spawn Group
    ---@param tCoalition number coalition.side 0 for Neutral, 1 for Red, 2 for Blue.
    ---@param spawnData table
    ---@return Group|nil
    function AGRange:SpawnGroup(tCoalition,spawnData)
        Debug('AGRange:SpawnGroup')
        
        if not tCoalition or not spawnData then return end

        Debug('AGRange: SpawnGroup: '..spawnData.name..' for coalition: '..tCoalition)

        local newGroup = coalition.addGroup(SpawnCoalition[tCoalition],Group.Category.GROUND,spawnData)
        return newGroup
    end

    --- Spawn static target, not DCS.StaticObjcet
    ---@param tCoalition number coalition.side 0 for Neutral, 1 for Red, 2 for Blue.
    ---@param spawnNumber number toatal amount of target number.
    ---@return boolean
    function AGRange:SpawnStatic(tCoalition,spawnNumber)
        Debug('AGRange:SpawnStatic')

        if not tCoalition or not spawnNumber then return end
        if spawnNumber < 1 then return true end

        for i=1,spawnNumber,1 do
            local spawnPoint = self:GetRandSpawnPoint()

            if not spawnPoint then
                spawnPoint = {x=self.point.x,y=self.point.z}
            end

            local coalitionName = {
                [coalition.side.RED] = 'Red',
                [coalition.side.BLUE] = 'Blue',
            }

            local rangeType = self.rangeType
            if rangeType == RangeType.AIRBASE or rangeType == RangeType.HELI then
                rangeType = RangeType.NORMAL
            end

            local templateTable = AGRange.TargetTemplates[rangeType]
            local type = templateTable[tCoalition][math.random(1,#templateTable[tCoalition])]
            if not type then return end
            local spawnData = {}
            spawnData.name = self.name..'_'..coalitionName[tCoalition]..'_Static-'..i
            spawnData.task = "Ground Nothing"
            spawnData.units = {
                [1] = {
                    x = spawnPoint.x,
                    y = spawnPoint.y,
                    name = spawnData.name..'-1',
                    type = type
                },
            }

            local newGroup = getGroup(self:SpawnGroup(tCoalition,spawnData))
            if not newGroup then return end

            local Controller = newGroup:getController()
            timer.scheduleFunction(self.setGroupAlarm_Green,Controller,timer.getTime()+1)

            table.insert(self.spawnedGroups[tCoalition], newGroup)
        end 

        return true
    end

    --- Spawn moving target.
    ---@param tCoalition number coalition.side 0 for Neutral, 1 for Red, 2 for Blue.
    ---@param spawnNumber number toatal amount of target number.
    ---@return boolean
    function AGRange:SpawnMoving(tCoalition,spawnNumber)
        Debug('AGRange:SpawnMoving')

        if not tCoalition or not spawnNumber then return end
        if spawnNumber < 1 then return true end

        local rangeHeading = self.heading or DEFAULT_RANGE_HEADING
        local pathTable = {
            {
                start = self.verticies[1],
                destination = self.verticies[3],
                startAngleMin = rangeHeading-90,
                destAngleMin = rangeHeading+90,
                add = 90,
            },
            {
                start = self.verticies[2],
                destination = self.verticies[4],
                startAngleMin = rangeHeading+180,
                destAngleMin = rangeHeading,
                add = 90,
            },
            {
                start = self.verticies[3],
                destination = self.verticies[1],
                startAngleMin = rangeHeading+90,
                destAngleMin = rangeHeading-90,
                add = 90,
            },
            {
                start = self.verticies[4],
                destination = self.verticies[2],
                startAngleMin = rangeHeading,
                destAngleMin = rangeHeading+180,
                add = 90,
            },
        }

        for i=1,spawnNumber,1 do
            local path = pathTable[math.random(1,#pathTable)]

            local spawnPoint = Utils.getRandPointInCircle(path.start,self.radius/2,100,path.startAngleMin+path.add,path.startAngleMin)

            if not spawnPoint then
                spawnPoint = {x=self.point.x,y=self.point.z}
            end
            
            local destinationPoint = Utils.getRandPointInCircle(path.destination,self.radius/2,100,path.destAngleMin+path.add,path.destAngleMin)

            local coalitionName = {
                [coalition.side.RED] = 'Red',
                [coalition.side.BLUE] = 'Blue',
            }

            local rangeType = self.rangeType
            if rangeType == RangeType.AIRBASE or rangeType == RangeType.HELI then
                rangeType = RangeType.NORMAL
            end

            local templateTable = AGRange.TargetTemplates[rangeType]
            local type = templateTable[tCoalition][math.random(1,#templateTable[tCoalition])]
            if not type then return end
            local spawnData = {}
            spawnData.name = self.name..'_'..coalitionName[tCoalition]..'_Moving-'..i
            spawnData.task = "Ground Nothing"
            spawnData.units = {
                [1] = {
                    x = spawnPoint.x,
                    y = spawnPoint.y,
                    name = spawnData.name..'-1',
                    type = type
                },
            }

            local newGroup = getGroup(self:SpawnGroup(tCoalition,spawnData))
            if not newGroup then return end

            local Controller = newGroup:getController()
            timer.scheduleFunction(self.setGroupAlarm_Green,Controller,timer.getTime()+1)

            local speed = math.random(DEFAULT_TARGET_SPEED.min, DEFAULT_TARGET_SPEED.max)
            local MovingMission = self:getTaskOnRoad(spawnPoint,destinationPoint,speed)
            timer.scheduleFunction(function (vars)
                local Controller = vars.Controller
                local Mission = vars.Mission
                if not Controller or not Mission then return end

                Controller:setTask(Mission)
            end,{Controller = Controller, Mission = MovingMission},timer.getTime()+1)

            table.insert(self.spawnedGroups[tCoalition], newGroup)

        end

        return true
    end

    --- Despawn targets
    function AGRange:DespawnTargets()
        if DebugMod then
            Debug('AGRange:DespawnTargets')
        end

        self.spawnedGroups = self.spawnedGroups or Utils.deepCopy(SpawnedGroups_Default)

        for tCoalition,groups in pairs(self.spawnedGroups) do
            for i,group in ipairs(groups) do
                if group and group.isExist then
                    if group:isExist() then
                        group:destroy()
                    end
                end
            end
        end
    end

    --- Add target template
    ---@param targetGroup string|Group
    ---@param rangetype string RangeType
    ---@param coalition number coalition.side 0 for Neutral, 1 for Red, 2 for Blue.
    ---@return Group|nil if the group is added.
    function AGRange.AddTargetType(targetGroup,rangetype,coalition)
        local tempGroup = getGroup(targetGroup)
        if not tempGroup then return end

        local coalition = coalition or tempGroup:getCoalition()

        local units = tempGroup:getUnits()
        for i,unit in ipairs(units) do
            table.insert(AGRange.TargetTemplates[rangetype][coalition],unit:getTypeName())
        end

        return tempGroup
    end

    --- Retuen RangeType
    function AGRange.getRangeType()
        return Utils.deepCopy(RangeType)
    end

    --- Show results.
    ---@param reason string|nil reason why training ends.
    ---@param Error boolean if training ends due to an player error.
    function AGRange:ShowResults(reason,Error)
        Debug('AGRange:ShowResults')
        local playerUnit = getFirstUnit(self.playerGroup)
        if not playerUnit then
            self:Init(true)
            return
        end
        
        local msg = ''
        msg = msg..'=================================='
        msg = msg..'\n           本次训练结果: \n'
        msg = msg..'=================================='

        if self.playerData.startTime then
            msg = msg..'\n开始时间: '..Format_time(self.playerData.startTime)
            msg = msg..'\n持续时间: '..(math.floor((timer.getAbsTime() - self.playerData.startTime)*10)/10)..'s'
        end

        msg = msg..'\n击杀敌方目标: '..self.playerData.enemyKilled
        msg = msg..'\n击杀友方目标: '..self.playerData.allyKilled

        local cloesestEnemyDistanceTest = (math.floor(self.playerData.cloesestEnemyDistance*10)/10)..'米'
        if self.playerData.cloesestEnemyDistance >= 15000 then
            cloesestEnemyDistanceTest = '非常远.'
        end
        msg = msg..'\n敌方最近时的距离: '..cloesestEnemyDistanceTest
        msg = msg..'\n=================================='

        if reason then
            msg = msg..'\n           训练结束: '..reason
            msg = msg..'\n=================================='
        end

        trigger.action.outTextForUnit(playerUnit:getID(),msg,60,true)
        --Sound
        if self.playSound then
            if not Error then
                trigger.action.outSoundForUnit(playerUnit:getID(),'audio/SFX/success.ogg')
            end

            if Error then
                trigger.action.outSoundForUnit(playerUnit:getID(),'audio/SFX/fail.ogg')
            end
        end

        -- self:writeFile(reason)
    end

--- MonitorFunction
    function AGRange._MonitorFunction(vars,time)
        local self = vars.context
        local repeatTime = vars.repeatTime

        if DebugMod then
            Debug('AGRange._MonitorFunction')
        end
        
        local playerGroup = getGroup(self.playerGroup)
        if not playerGroup then
            self:Init(true)
            return
        end

        local playerUnit = getFirstUnit(self.playerGroup)
        if not playerUnit then
            self:Init(true)
            return
        end
        
        local playerPoint = playerUnit:getPoint()
        if not playerPoint then
            self:Init(true)
            Utils.Debug('脚本意外终止 - 可能原因: 无法获取玩家位置.')
            return
        end

        -- -- 玩家从任意一个方向进入靶场
        -- if Utils.pointInPolygon(playerPoint,self.verticies) then
        --     self.EndTraining({rangeObj = self,reason = '玩家进入了靶场',Error = true})
        --     return
        -- end

        -- 更新最近距离
        local targetIsExist = false

        for tCoalition,group in pairs(self.spawnedGroups[coalition.side.RED]) do
            if group and group.isExist then
                if group:isExist() then
                    for j, unit in ipairs(group:getUnits()) do
                        if unit:isExist() then
                            if unit:getLife() >= 1 then
                                targetIsExist = true

                                local targetPoint = unit:getPoint()
                                local distance = Utils.get3DDist(playerPoint,targetPoint)
                                if distance < self.playerData.cloesestEnemyDistance then
                                    self.playerData.cloesestEnemyDistance = distance
                                end
                            end
                        end
                    end
                end
            end
        end

        -- 所有敌方目标被摧毁
        if not targetIsExist then
            self.EndTraining({rangeObj = self,reason = '所有敌方目标被摧毁'})
            return
        end

        return time + repeatTime
    end

    function AGRange:Start(Delay,RepeatScanSeconds)
        Debug('AGRange:Start')
        local tDelay = Delay or 1
        local RepeatScanInterval = RepeatScanSeconds or AGRange.MonitorRepeatTime

        if self.MonitorID then
            self:Stop()
        end

        self.MonitorID = timer.scheduleFunction(self._MonitorFunction,{context = self,repeatTime = RepeatScanInterval},timer.getTime() + tDelay)
    end
    
    function AGRange:Stop()
        Debug('AGRange:Stop')
        if not self.MonitorID then return end

        timer.removeFunction(self.MonitorID)
        self.MonitorID = nil
    end
--- MonitorFunction

--- ============ Create Player Menu. ============
    function AGRange.createPlayerMenu(playerGroup,rangeObj)
        Debug('AGRange.createPlayerMenu')

        local unit = getFirstUnit(playerGroup)
        if not unit then return end
        
        local groupID = playerGroup:getID()
        local groupName = playerGroup:getName()
        Debug('AGRange.createPlayerMenu: groupName: '..groupName)

        local unitCategory = unit:getCategoryEx()
        if not unitCategory then return end

        if AGRange.menu[groupName] then
            missionCommands.removeItemForGroup(groupID,AGRange.menu[groupName])
            AGRange.menu[groupName] = nil
        end

        local mainMenuName = Localizations:translate('AG Range','cn')
        local mainMenu = missionCommands.addSubMenuForGroup(groupID,mainMenuName)

        if not rangeObj then
            --Set command for all players.
            for rangeType, rangeZones in pairs(AGRange.RangeTypes) do

                if unitCategory == Unit.Category.HELICOPTER then
                    if rangeType ~= RangeType.NORMAL then
                        local rangeTypeName = rangeType
                        if rangeTypeName == RangeType.HELI then
                            rangeTypeName = RangeType.NORMAL
                        end

                        local regiseterRangeFuncName = Localizations:translate(rangeTypeName..' range','cn')
                        missionCommands.addCommandForGroup(groupID,regiseterRangeFuncName,mainMenu,AGRange.RegisterPlayer,{playerGroup = playerGroup,rangeType = rangeType})
                    end
                end

                if unitCategory == Unit.Category.AIRPLANE then
                    if rangeType ~= RangeType.HELI then
                        local regiseterRangeFuncName = Localizations:translate(rangeType..' range','cn')
                        missionCommands.addCommandForGroup(groupID,regiseterRangeFuncName,mainMenu,AGRange.RegisterPlayer,{playerGroup = playerGroup,rangeType = rangeType})
                    end
                end
            
            end
        end

        --Set command after a range is assigned.
        if rangeObj then
            local RefreshTargetsFuncName = Localizations:translate('Refresh Targets','cn')
            missionCommands.addCommandForGroup(groupID,RefreshTargetsFuncName,mainMenu,AGRange.SpawnTargets,{rangeObj = rangeObj})

            for tCalition, group in pairs(rangeObj.spawnedGroups) do
                local coalitionNames = {
                    [coalition.side.NEUTRAL] = '[NEUTRAL]',
                    [coalition.side.RED] = '[RED]',
                    [coalition.side.BLUE] = '[BLUE]',
                }

                local SmokeTargetsFuncName = Localizations:translate(string.format('Mark all %s targets in range area.',coalitionNames[tCalition]),'cn')
                missionCommands.addCommandForGroup(groupID,SmokeTargetsFuncName,mainMenu,AGRange.SmokeTargets,{rangeObj = rangeObj,coalition = tCalition})
            end

            local EndTrainingFuncName = Localizations:translate('End training','cn')
            missionCommands.addCommandForGroup(groupID,EndTrainingFuncName,mainMenu,AGRange.EndTraining,
            {rangeObj = rangeObj,reason = Localizations:translate('Player end training manually.','cn'),deregisterRange = true})
        
            local SoundSwitchFuncName = Localizations:translate('Sound: Off','cn')
            if rangeObj.playSound then
                SoundSwitchFuncName = Localizations:translate('Sound: On','cn')
            end
            missionCommands.addCommandForGroup(groupID,SoundSwitchFuncName,mainMenu,AGRange.SoundSwich,{rangeObj = rangeObj})
        end


        AGRange.menu[groupName] = mainMenu
        return self
    end

    -- Listen birth event.
    local birthEvnetListener = {}
    function birthEvnetListener:onEvent(event)
        if event.id == world.event.S_EVENT_BIRTH then
            local unit = getUnit(event.initiator)
            if not unit then return end

            local group = getGroup(event.initiator:getGroup())
            if not group then return end

            local playerName = unit:getPlayerName()
            if not playerName then return end

            AGRange.createPlayerMenu(group)

        end
    end
    world.addEventHandler(birthEvnetListener)

-- Menu functions.
    ---Assign player to a unactivate range.
    ---@param vars table
    ---@param vars.playerGroup Group object, player who requests a range.
    ---@param vars.rangeType string, requested range type.
    ---@return AGRange|nil
    function AGRange.RegisterPlayer(vars)
        Debug('AGRange.RegisterPlayer')

        if not vars or type(vars)~='table' then return end
        
        local unit = getFirstUnit(vars.playerGroup)
        if not unit then return end

        local category = unit:getCategoryEx()
        if vars.rangeType == RangeType.NORMAL and category == Unit.Category.HELICOPTER then
            vars.rangeType = RangeType.HELI
        end

        local range = getAvailableRange(vars.rangeType)
        if not range then
            trigger.action.outTextForUnit(Localizations:translate('No available range for this type','cn'),5)
            return
        end
        
        range:Init(true)
        range.playerGroup = vars.playerGroup
        AGRange.RangePlayers[range.name] = vars.playerGroup
        range:setActive()

        local result = AGRange.SpawnTargets({rangeObj = range})
        if not result then 
            range:Init(true)
            return 
        end

        AGRange.createPlayerMenu(vars.playerGroup,range)
        return range
    end

    --- Spawn targets for player.
    ---@param vars table
    ---@param vars.rangeObj AGRange object, range for player.
    ---@return boolean if SpawnTargets is successful.
    function AGRange.SpawnTargets(vars)
        Debug('AGRange.SpawnTargets')
        
        if not vars or type(vars)~='table' then return end

        local self = vars.rangeObj
        if not self then return end

        local playerGroup = getGroup(self.playerGroup)
        if not playerGroup then return end

        self:DespawnTargets()

        self.playerData = Utils.deepCopy(PlayerData_Default)

        local RedStaticNumber = math.random(TotalStaticNum-TotalBlueNum,TotalStaticNum)
        local RedMovingNumber = TotalRedNum-RedStaticNumber

        local BlueStaticNumber = TotalStaticNum-RedStaticNumber
        local BlueMovingNumber = TotalBlueNum-BlueStaticNumber

        local RedStatic = self:SpawnStatic(coalition.side.RED,RedStaticNumber)
        local RedMoving = self:SpawnMoving(coalition.side.RED,RedMovingNumber)
        
        local BlueStatic = self:SpawnStatic(coalition.side.BLUE,BlueStaticNumber)
        local BlueMoving = self:SpawnMoving(coalition.side.BLUE,BlueMovingNumber)

        if RedStatic and RedMoving and BlueStatic and BlueMoving then
            local playerUnit = getFirstUnit(playerGroup)
            if not playerUnit then
                self:Init(true)
                return
            end
            if not playerUnit:isExist() then
                self:Init(true)
                return
            end

            self.playerData.startTime = timer.getAbsTime()
            local startTime = Format_time(self.playerData.startTime)

            local msg = ''
            msg = msg..'==================================\n'
            msg = msg..'    训练开始, 开始时间: '..startTime..' (游戏内)\n'
            msg = msg..'==================================\n'
            msg = msg..'           靶场范围坐标:\n'
            msg = msg..'1: MGRS: '..Format_Coord(self.verticies[1],'MGRS')..' | '..Format_Coord(self.verticies[1],'LL')..'\n'
            msg = msg..'2: MGRS: '..Format_Coord(self.verticies[2],'MGRS')..' | '..Format_Coord(self.verticies[2],'LL')..'\n'
            msg = msg..'3: MGRS: '..Format_Coord(self.verticies[3],'MGRS')..' | '..Format_Coord(self.verticies[3],'LL')..'\n'
            msg = msg..'4: MGRS: '..Format_Coord(self.verticies[4],'MGRS')..' | '..Format_Coord(self.verticies[4],'LL')..'\n'

            --Debug
            msg = msg..'\n如果遇到统计结果和自己预想的不一样可以及时联系 Buta.\n'
            msg = msg..'目前的测试都是单人测试, 如果你的目标被别人打了, 请留意 击杀提示&统计结果 是否正确.\n'
            msg = msg..'遇到靶场不工作了可以尝试结束训练后重新开始训练.\n'
            msg = msg..'如果无法结束训练及时联系 Buta.\n'
            if self.rangeType == RangeType.AIRBASE then
                msg = msg..'\n当前还没有对[机场]靶场做特化, 所以用起来暂时跟普通靶场一样.\n'
            end

            trigger.action.outTextForUnit(playerUnit:getID(),msg,300,true)

            local playerName = playerUnit:getPlayerName()
            self:DrawZone()

            --Sound
            if self.playSound then
                trigger.action.outSoundForUnit(playerUnit:getID(),'audio/SFX/success.ogg')
            end

            if DebugMod then
                Debug('timer.getTime(): '..Format_time(timer.getTime()))
                Debug('timer.getAbsTime(): '..Format_time(timer.getAbsTime()))
            end

            self:Start()
            return true
        end
    end

    --- End training.
    ---@param vars table
    ---@param vars.rangeObj AGRange object, range for player.
    ---@param vars.reason string|nil reason why training ends.
    ---@param vars.notShowResults boolean decied to end training with or without showing results.
    ---@param vars.deregisterRange boolean, if true, set everything to initial.
    ---@param vars.Error boolean if training ends due to an player error.
    function AGRange.EndTraining(vars)
        if DebugMod then
            Debug('AGRange.EndTraining')
        end
        
        if not vars or type(vars)~='table' then return end

        local self = vars.rangeObj
        if not self then return end

        local reason = vars.reason or 'N/A'
        local Error = vars.Error
        local notShowResults = vars.notShowResults

        if not notShowResults then self:ShowResults(reason,Error) end

        if vars.deregisterRange then
            AGRange.createPlayerMenu(self.playerGroup)
        end

        if not vars.deregisterRange then
            AGRange.createPlayerMenu(self.playerGroup,self)
        end

        self:Init(vars.deregisterRange)
    end

    --- Sound swich.
    ---@param vars table
    ---@param vars.rangeObj AGRange object.
    function AGRange.SoundSwich(vars)
        Debug('AGRange.SoundSwich')
        
        if not vars or type(vars)~='table' then return end

        local self = vars.rangeObj
        if not self then return end
        
        local playerUnit = getFirstUnit(self.playerGroup)
        if not playerUnit then
            self:Init(true)
            return
        end
        if not playerUnit:isExist() then
            self:Init(true)
            return
        end

        if not self.playSound then
            self.playSound = true
            trigger.action.outSoundForUnit(playerUnit:getID(),'audio/SFX/success.ogg')
            trigger.action.outTextForUnit(playerUnit:getID(),Localizations:translate('Sound: On','cn'),5)
        else
            self.playSound = false
            trigger.action.outSoundForUnit(playerUnit:getID(),'audio/SFX/fail.ogg')
            trigger.action.outTextForUnit(playerUnit:getID(),Localizations:translate('Sound: Off','cn'),5)
        end

        AGRange.createPlayerMenu(self.playerGroup,self)
    end

    --- Smoke targets
    ---@param vars table
    ---@param vars.rangeObj AGRange object.
    ---@param vars.coalition number coalition.side 0 for Neutral, 1 for Red, 2 for Blue.
    function AGRange.SmokeTargets(vars)
        local self = vars.rangeObj
        if not self then return end

        local tCoalition = vars.coalition
        if not tCoalition then return end

        local currentTime = timer.getTime()
        self.smokeMarkerTime = self.smokeMarkerTime or {}

        if self.smokeMarkerTime[tCoalition] and (currentTime-self.smokeMarkerTime[tCoalition]<DEFAULT_SMOKE_DISAPPEAR_TIME) then
            local playerUnit = getFirstUnit(self.playerGroup)
            if not playerUnit then return end

            local msg = string.format(Localizations:translate('Please wait %d second(s) for next mark.','cn'),DEFAULT_SMOKE_DISAPPEAR_TIME-(currentTime-self.smokeMarkerTime[tCoalition]))
            trigger.action.outTextForUnit(playerUnit:getID(),msg,10)
        end

        for i,group in ipairs(self.spawnedGroups[tCoalition]) do
            if group and group.isExist then
                if group:isExist() then
                    local unit = getFirstUnit(group,true)
                    if unit then
                        local point = unit:getPoint()
                        local smokePoint = Utils.makeVec3(Utils.getRandPointInCircle(point,30))
                        smokePoint.y = point.y

                        local smokeColor = {
                            [coalition.side.RED] = trigger.smokeColor.Red,
                            [coalition.side.BLUE] = trigger.smokeColor.Blue
                        }

                        local smokeName = 'Smoke_'..group:getName()
                        trigger.action.smoke(smokePoint,smokeColor[tCoalition],smokeName)
                        self.smokeMarkerTime[tCoalition] = timer.getTime()

                        timer.scheduleFunction(function (smokeName)
                            trigger.action.effectSmokeStop(smokeName)
                        end,smokeName,timer.getTime()+DEFAULT_SMOKE_DISAPPEAR_TIME) --the smoke in game will be disappear in 5mins.
                    end
                end
            end
        end

    end

-- Menu functions end.

-- Event handlers.
local ev = {}
    function ev:onEvent(event)
        if event.id == world.event.S_EVENT_PLAYER_LEAVE_UNIT then
            local initiator = event.initiator
            if not initiator then return end
            if not initiator:isExist() then return end
            if not initiator.getPlayerName then return end
            
            local playerName = initiator:getPlayerName()
            if not playerName then return end

            for zoneName,playerGroup in pairs(AGRange.RangePlayers) do
                local playerUnit = getFirstUnit(playerGroup)
                if not playerUnit then
                    Debug('Found other range: '..zoneName..' without it\'s playerGroup. Init range.')
                    AGRange.AllRanges[zoneName]:Init(true)
                end

                if playerUnit then
                    local rangePlayerName = playerUnit:getPlayerName()
                    if playerName == rangePlayerName then
                        AGRange.AllRanges[zoneName]:Init(true)
                        return
                    end
                end
            end
        end
    end
    world.addEventHandler(ev)

    local hitEvent = {}
    function hitEvent:onEvent(event)
        if event.id == world.event.S_EVENT_HIT then
            Debug('hitEvent')
            local initiator = getUnit(event.initiator,true,true)
            if not initiator then return end

            local initiatorName = initiator:getName()

            local targetUnit = event.target
            if not targetUnit then return end
            if targetUnit:getLife() >= 1 then return end

            local targetGroup = getGroup(targetUnit:getGroup())
            if not targetGroup then return end

            local tCoalition = targetUnit:getCoalition()
            local pCoalition = initiator:getCoalition()
            
            local otherSide = {
                [coalition.side.BLUE] = coalition.side.RED,
                [coalition.side.RED] = coalition.side.BLUE,
            }

            for rangeName, playerGroup in pairs(AGRange.RangePlayers) do
                local isZonePlayer = false
                local isZoneTarget = false
                local range = AGRange.AllRanges[rangeName]
                if not range or not range.spawnedGroups or type(range.spawnedGroups)~='table' then return end
                Debug('hitEvent: rangeName: '..rangeName)

                local rangePlayerUnit = getFirstUnit(playerGroup)
                if not rangePlayerUnit then
                    range:Init(true)
                    return
                end

                local rangePlayerName = getFirstUnit(playerGroup):getName()
                if not rangePlayerName then return end

                Debug('hitEvent: rangePlayerName: ')
                Debug(rangePlayerName)

                if rangePlayerName == initiatorName then
                    isZonePlayer = true
                end

                for i,group in ipairs(range.spawnedGroups[tCoalition]) do

                    if group:isExist() then
                        if targetGroup:getName() == group:getName() then
                            Debug('hitEvent: targetGroup: ')
                            Debug(targetGroup:getName())

                            isZoneTarget = true
                        end
                    end
                end

                if isZonePlayer and isZoneTarget then
                    Debug('hitEvent: isZonePlayer and isZoneTarget')
                    if tCoalition == otherSide[pCoalition] then
                        range.playerData.enemyKilled = range.playerData.enemyKilled + 1
                        trigger.action.outTextForUnit(initiator:getID(),'击杀敌方目标: '..targetUnit:getTypeName(),2)
                    end

                    if tCoalition == pCoalition then
                        range.playerData.allyKilled = range.playerData.allyKilled + 1
                        trigger.action.outTextForUnit(initiator:getID(),'击杀友方目标: '..targetUnit:getTypeName(),2)
                    end

                    break
                end
            end
        end
    end
    world.addEventHandler(hitEvent)
-- Event handlers.
end
